Go: a recipe option binds to its field's declared type - #8639
Draft
knutwannheden wants to merge 2 commits into
Draft
Go: a recipe option binds to its field's declared type#8639knutwannheden wants to merge 2 commits into
knutwannheden wants to merge 2 commits into
Conversation
Every non-string option on every Go recipe was unreachable from the Moderne CLI. `mod run --recipe=org.openrewrite.golang.AddImport -PonlyIfReferenced=false` failed with `Internal error: reflect.Set: value of type string is not assignable to type bool`, for both `true` and `false`. The registry bound options by `f.Set(reflect.ValueOf(val))` with no conversion. `val` is whatever `encoding/json` decoded into `map[string]any`, and the CLI declares `-P` as picocli `Map<String, Object>`, so every command-line option arrives as a string whatever its declared type. A JSON number decodes to `float64`, so an `int` field failed identically even for a correctly typed caller. Values are now coerced to the field's declared type before being set, mirroring Jackson (which the Java host reaches via RecipeLoader's `convertValue` fallback, not `RecipeIntrospectionUtils.convert`) and the C# server's `Convert.ChangeType`. An unconvertible value produces an `OptionBindError` naming the recipe, option, declared type and value, which `handlePrepareRecipe` returns as `-32602` rather than a recovered panic. This requires `RecipeConstructor` to return an error. `PrepareRecipe` decodes with `UseNumber` so an integer option past 2^53 survives: `9007199254740993` otherwise bound as `9007199254740992` with no error at all. Option names resolve to fields case-insensitively when the capitalized spelling misses, so `url` reaches a `URL` field as it does on the Java host (`ACCEPT_CASE_INSENSITIVE_PROPERTIES`) and in C# (`BindingFlags.IgnoreCase`). An option naming no field stays ignored, matching Java (Jackson with `FAIL_ON_UNKNOWN_PROPERTIES` disabled) and C#. `GolangRecipeIntegTest` covers the wire path the CLI uses: with the coercion reverted, `booleanRecipeOptionArrivingAsAString` reproduces the reported `reflect.Set` message verbatim.
RecipeConstructor is exported and takes map[string]any, so options reach the
binder from two origins: JSON-decoded wire values (string, json.Number) and
Go values passed directly in-process. Only the first was accepted, so
`Constructor(map[string]any{"count": 42})` bound to an `int` field via the
assignable fast path but failed against `int64`, `uint64`, `float64` or
`string` — the same value that binds when it arrives as json.Number("42").
The conversion helpers now read any integer, unsigned or float kind through
reflection, range-checked as before.
Also corrects two comments. Register's doc claimed "for recipes without
options, the prototype itself is returned", which newReflectConstructor has
never done — it allocates a zero-valued instance, discarding any field set on
the prototype. And coerceOption claimed its conversions "mirror" Jackson and
Convert.ChangeType, which both read a numeric 1/0 as a bool where this does
not; the deviation is now stated at the case it applies to.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Setting any non-string option on any Go recipe from the Moderne CLI fails outright.
mod run . --recipe=org.openrewrite.golang.AddImport -PpackagePath=strings -PonlyIfReferenced=falsedies withInternal error: reflect.Set: value of type string is not assignable to type bool, for bothtrueandfalse. Since-Pis the only way to set recipe options from the CLI, every non-string option on every Go recipe is currently unreachable.The mechanism
newReflectConstructorbound options onto the recipe struct with no conversion at all:valis whateverencoding/jsondecoded intomap[string]any. The CLI declares-Pas picocliMap<String, Object>, soonlyIfReferenced=falseparses to the Java String"false", serialises as a JSON string, and lands as a Gostringagainst aboolfield. Fixing only string→bool would not have been enough: a JSON number decodes tofloat64, so anintfield failed identically even for a caller sending a correctly typed number.The fix
Values are coerced to the field's declared type before being set. The reference for what a recipe option may be written as is Jackson, not
RecipeIntrospectionUtils.convert—convertonly handlesStringtargets and string→enum, and passes"false"straight through to abooleanparameter. Java tolerates the flag becauseconstructRecipethrows andRecipeLoaderfalls back tomapper.convertValue. The closest in-repo peer is C#RewriteRpcServer.ConvertOptionValue, which deserialises the fragment into the declared property type.Two further defects surfaced while matching that policy:
PrepareRecipenow decodes withUseNumber, aspkg/rpc/rpc_object_data.goalready does. Without it an integer option past 2^53 is silently corrupted before the binder sees it —9007199254740993bound as9007199254740992with no error, since a truncation check only rejects non-integral floats.Option names resolve to fields case-insensitively when the capitalised spelling misses, so
urlreaches aURLfield as it does on the Java host (ACCEPT_CASE_INSENSITIVE_PROPERTIES) and in C# (BindingFlags.IgnoreCase). Previously it producedUrl, missed, and dropped the value silently, leaving the recipe running with a zero-valued option.An option naming no field stays ignored, matching Java (Jackson with
FAIL_ON_UNKNOWN_PROPERTIESdisabled) and C#. Python is the only peer that errors, and only as a side effect ofrecipe_class(**options).Breaking change
RecipeConstructorgains an error return, going fromfunc(map[string]any) Recipetofunc(map[string]any) (Recipe, error). There is no additive way to do this in Go, and no error channel means no way to name the failing option — today it is a recovered panic reported as-32603 Internal errorwith no mention of which recipe or option. It is now anOptionBindErrornaming the recipe, option, declared type and value, returned as-32602.The module is
v0.0.31, pre-1.0. The only external consumer ismoderneinc/recipes-go, which callsConstructorin exactly two test files, both passingniloptions and so incapable of producing a bind error. The alternative — panicking with a typed error and recovering at the RPC boundary — would preserve compatibility but keeps the broken contract alive for anyone still calling the old signature, and uses panic as control flow for a user typing a bad flag value.Tests
GolangRecipeIntegTest.booleanRecipeOptionArrivingAsAStringruns the wire path the CLI uses, asserting thatonlyIfReferencedbound from a JSON string takes effect in both directions. It is mutation-checked: with the coercion reverted it reproduces the reported message verbatim,reflect.Set: value of type string is not assignable to type bool.TestPrepareRecipeBindsOptionsFromWireJSONis likewise mutation-checked against theUseNumberdecode, failing withexpected 9007199254740993, actual 9007199254740992.pkg/recipe/options_test.gocovers the conversion matrix directly: string↔bool both ways,float64/string/json.Numberto int and uint, width overflow, non-integral and out-of-int64-range floats, pointer and slice options, and unconvertible values.Scope
pkg/visitor/init.golooked like it needed the same treatment and does not — bothf.Set(reflect.ValueOf(v))calls assign the visitor pointer to itsSelffield for virtual dispatch and never see wire data.VisitorOptionsincmd/rpc/main.gois decoded and then read nowhere in the repo.Whether the CLI should also coerce Java-side, where it does know the declared type from the option descriptors, is left open. The Go binder has to be robust against whatever any RPC client sends either way.
The two
recipes-gotest call sites are tracked separately and can only be adapted after this lands, since the fix does not compile against therewrite-gothat repo currently resolves.